import should from 'should';
import 'should-http';
import 'should-sinon';
import '../lib/asserts';

import * as sinon from 'sinon';
import * as nock from 'nock';
import proxyquire from 'proxyquire';

import debugLib from 'debug';
import { Environments } from 'bitgo';

import { SSL_OP_NO_TLSv1 } from 'constants';
import { TlsConfigurationError, NodeEnvironmentError } from '../../src/errors';

import { app as expressApp, startup, createServer, createBaseUri, prepareIpc } from '../../src/expressApp';

// commonJS imports to allow stubbing
const path = require('path');
const http = require('http');
const https = require('https');
const fs = require('fs');

nock.disableNetConnect();
proxyquire.noPreserveCache();

describe('Bitgo Express', function () {
  describe('server initialization', function () {
    const validPrvJSON =
      '{"61f039aad587c2000745c687373e0fa9":"xprv9s21ZrQH143K3EuPWCBuqnWxydaQV6et9htQige4EswvcHKEzNmkVmwTwKoadyHzJYppuADB7Us7AbaNLToNvoFoSxuWqndQRYtnNy5DUY2"}';
    const validLightningSignerConfigJSON = '{"fakeid":{"url": "https://127.0.0.1:8080","tlsCert":"dummy"}}';

    it('should require NODE_ENV to be production when running against prod env', function () {
      const envStub = sinon.stub(process, 'env').value({ NODE_ENV: 'production' });

      try {
        (() =>
          expressApp({
            env: 'prod',
            bind: 'localhost',
          } as any)).should.not.throw();

        process.env.NODE_ENV = 'dev';
        (() =>
          expressApp({
            env: 'prod',
            bind: 'localhost',
          } as any)).should.throw(NodeEnvironmentError);
      } finally {
        envStub.restore();
      }
    });

    it('should disable NODE_ENV check if disableenvcheck argument is given', function () {
      const envStub = sinon.stub(process, 'env').value({ NODE_ENV: 'dev' });

      try {
        (() =>
          expressApp({
            env: 'prod',
            bind: 'localhost',
            disableEnvCheck: true,
          } as any)).should.not.throw();
      } finally {
        envStub.restore();
      }
    });

    it('should require TLS for prod env when listening on external interfaces', function () {
      const args: any = {
        env: 'prod',
        bind: '1',
        disableEnvCheck: true,
        disableSSL: false,
        crtPath: undefined as string | undefined,
        keyPath: undefined as string | undefined,
      };

      (() => expressApp(args)).should.throw(TlsConfigurationError);

      args.bind = 'localhost';
      (() => expressApp(args)).should.not.throw();

      args.bind = '1';
      args.env = 'test';
      (() => expressApp(args)).should.not.throw();

      args.disableSSL = true;
      args.env = 'prod';
      (() => expressApp(args)).should.not.throw();

      delete args.disableSSL;
      args.crtPath = '/tmp/cert.pem';
      (() => expressApp(args)).should.throw(TlsConfigurationError);

      delete args.crtPath;
      args.keyPath = '/tmp/key.pem';
      (() => expressApp(args)).should.throw(TlsConfigurationError);
    });

    it('should require both keypath and crtpath when using TLS, but TLS is not required', function () {
      const args: any = {
        env: 'test',
        bind: '1',
        keyPath: '/tmp/key.pem',
        crtPath: undefined as string | undefined,
      };

      (() => expressApp(args)).should.throw(TlsConfigurationError);

      delete args.keyPath;
      args.crtPath = '/tmp/cert.pem';

      (() => expressApp(args)).should.throw(TlsConfigurationError);
    });

    it('should create an http server when not using TLS', async function () {
      const createServerStub = sinon.stub(http, 'createServer');

      const args: any = {
        env: 'prod',
        bind: 'localhost',
      };

      createServer(args, null as any);

      createServerStub.should.be.calledOnce();
      createServerStub.restore();
    });

    it('should create an https server when using TLS', async function () {
      const createServerStub = sinon.stub(https, 'createServer');
      const readFileAsyncStub = sinon
        .stub(fs.promises, 'readFile' as any)
        .onFirstCall()
        .resolves('key')
        .onSecondCall()
        .resolves('cert');

      const args: any = {
        env: 'prod',
        bind: '1.2.3.4',
        crtPath: '/tmp/crt.pem',
        keyPath: '/tmp/key.pem',
      };

      await createServer(args, null as any);

      https.createServer.should.be.calledOnce();
      https.createServer.should.be.calledWith({ secureOptions: SSL_OP_NO_TLSv1, key: 'key', cert: 'cert' });

      createServerStub.restore();
      readFileAsyncStub.restore();
    });

    it('should create https server with sslkey and sslcert', async () => {
      const createServerStub = sinon.stub(https, 'createServer');
      const args: any = {
        env: 'test',
        bind: '1',
        sslKey: 'ssl-key',
        sslCert: 'ssl-cert',
      };

      try {
        await createServer(args, null as any);
        https.createServer.should.be.calledOnce();
        https.createServer.should.be.calledWith({ secureOptions: SSL_OP_NO_TLSv1, key: 'ssl-key', cert: 'ssl-cert' });
      } finally {
        createServerStub.restore();
      }
    });

    it('should output basic information upon server startup', () => {
      const logStub = sinon.stub(console, 'log');

      const args: any = {
        env: 'test',
      };

      startup(args, 'base')();

      logStub.should.have.callCount(3);
      logStub.should.have.been.calledWith('BitGo-Express running');
      logStub.should.have.been.calledWith(`Environment: ${args.env}`);
      logStub.should.have.been.calledWith('Base URI: base');

      logStub.restore();
    });

    it('should output custom root uri information upon server startup', () => {
      const logStub = sinon.stub(console, 'log');

      const args: any = {
        env: 'test',
        customRootUri: 'customuri',
      };

      startup(args, 'base')();

      logStub.should.have.callCount(4);
      logStub.should.have.been.calledWith('BitGo-Express running');
      logStub.should.have.been.calledWith(`Environment: ${args.env}`);
      logStub.should.have.been.calledWith('Base URI: base');
      logStub.should.have.been.calledWith(`Custom root URI: ${args.customRootUri}`);

      logStub.restore();
    });

    it('should output custom bitcoin network information upon server startup', () => {
      const logStub = sinon.stub(console, 'log');

      const args: any = {
        env: 'test',
        customBitcoinNetwork: 'customnetwork',
      };

      startup(args, 'base')();

      logStub.should.have.callCount(4);
      logStub.should.have.been.calledWith('BitGo-Express running');
      logStub.should.have.been.calledWith(`Environment: ${args.env}`);
      logStub.should.have.been.calledWith('Base URI: base');
      logStub.should.have.been.calledWith(`Custom bitcoin network: ${args.customBitcoinNetwork}`);

      logStub.restore();
    });

    it('should output signer mode upon server startup', () => {
      const logStub = sinon.stub(console, 'log');

      const args: any = {
        env: 'test',
        signerMode: 'signerMode',
      };

      startup(args, 'base')();

      logStub.should.have.callCount(4);
      logStub.should.have.been.calledWith('BitGo-Express running');
      logStub.should.have.been.calledWith(`Environment: ${args.env}`);
      logStub.should.have.been.calledWith('Base URI: base');
      logStub.should.have.been.calledWith(`External signer mode: ${args.signerMode}`);

      logStub.restore();
    });

    it('should output lightning signer file system path upon server startup', () => {
      const logStub = sinon.stub(console, 'log');

      const args: any = {
        env: 'test',
        lightningSignerFileSystemPath: 'lightningSignerFileSystemPath',
      };

      startup(args, 'base')();

      logStub.should.have.callCount(4);
      logStub.should.have.been.calledWith('BitGo-Express running');
      logStub.should.have.been.calledWith(`Environment: ${args.env}`);
      logStub.should.have.been.calledWith('Base URI: base');
      logStub.should.have.been.calledWith(`Lightning signer file system path: ${args.lightningSignerFileSystemPath}`);

      logStub.restore();
    });

    it('should create http base URIs', () => {
      const args: any = {
        bind: '1',
        port: 2,
      };

      createBaseUri(args).should.equal(`http://${args.bind}:${args.port}`);

      args.port = 80;
      createBaseUri(args).should.equal(`http://${args.bind}`);

      args.port = 443;
      createBaseUri(args).should.equal(`http://${args.bind}:443`);
    });

    it('should create https base URIs', () => {
      const args: any = {
        bind: '6',
        port: 8,
        keyPath: '3',
        crtPath: '4',
      };

      createBaseUri(args).should.equal(`https://${args.bind}:${args.port}`);

      args.port = 80;
      createBaseUri(args).should.equal(`https://${args.bind}:80`);

      args.port = 443;
      createBaseUri(args).should.equal(`https://${args.bind}`);
    });

    it('should set up logging with a logfile', () => {
      const resolveSpy = sinon.spy(path, 'resolve');
      const createWriteStreamSpy = sinon.spy(fs, 'createWriteStream');
      const logStub = sinon.stub(console, 'log');

      const args: any = {
        logFile: '/dev/null',
        disableProxy: true,
      };

      expressApp(args);

      path.resolve.should.have.been.calledWith(args.logFile);
      fs.createWriteStream.should.have.been.calledOnceWith(args.logFile, { flags: 'a' });
      logStub.should.have.been.calledOnceWith(`Log location: ${args.logFile}`);

      resolveSpy.restore();
      createWriteStreamSpy.restore();
      logStub.restore();
    });

    it('should enable specified debug namespaces', () => {
      const enableStub = sinon.stub(debugLib, 'enable');

      const args: any = {
        debugNamespace: ['a', 'b'],
        disableProxy: true,
      };

      expressApp(args);

      enableStub.should.have.been.calledTwice();
      enableStub.should.have.been.calledWith(args.debugNamespace[0]);
      enableStub.should.have.been.calledWith(args.debugNamespace[1]);

      enableStub.restore();
    });

    it('should configure a custom root URI', () => {
      const args: any = {
        customRootUri: 'customroot',
        env: undefined as string | undefined,
      };

      expressApp(args);

      should(args.env).equal('custom');
      Environments.custom.uri.should.equal(args.customRootUri);
    });

    it('should configure a custom bitcoin network', () => {
      const args: any = {
        customBitcoinNetwork: 'custombitcoinnetwork',
        env: undefined as string | undefined,
      };

      expressApp(args);

      should(args.env).equal('custom');
      Environments.custom.network.should.equal(args.customBitcoinNetwork);
    });

    it('should fail if IPC option is used on windows', async () => {
      const platformStub = sinon.stub(process, 'platform').value('win32');
      await prepareIpc('testipc').should.be.rejectedWith(/^IPC option is not supported on platform/);
      platformStub.restore();
    });

    it("should not remove the IPC socket if it doesn't exist", async () => {
      const statStub = sinon.stub(fs, 'statSync').throws({ code: 'ENOENT' });
      const unlinkStub = sinon.stub(fs, 'unlinkSync');
      await prepareIpc('testipc').should.be.resolved();
      unlinkStub.notCalled.should.be.true();
      statStub.restore();
      unlinkStub.restore();
    });

    it('should remove the socket before binding if IPC socket exists and is a socket', async () => {
      const statStub = sinon.stub(fs, 'statSync').returns({ isSocket: () => true });
      const unlinkStub = sinon.stub(fs, 'unlinkSync');
      await prepareIpc('testipc').should.be.resolved();
      unlinkStub.calledWithExactly('testipc').should.be.true();
      unlinkStub.calledOnce.should.be.true();
      statStub.restore();
      unlinkStub.restore();
    });

    it('should fail if IPC socket is not actually a socket', async () => {
      const statStub = sinon.stub(fs, 'statSync').returns({ isSocket: () => false });
      const unlinkStub = sinon.stub(fs, 'unlinkSync');
      await prepareIpc('testipc').should.be.rejectedWith(/IPC socket is not actually a socket/);
      unlinkStub.notCalled.should.be.true();
      statStub.restore();
      unlinkStub.restore();
    });

    it('should print the IPC socket path on startup', async () => {
      const logStub = sinon.stub(console, 'log');

      const args: any = {
        env: 'test',
        customRootUri: 'customuri',
        ipc: 'expressIPC',
      };

      startup(args, 'base')();
      logStub.should.have.been.calledWith('IPC path: expressIPC');
      logStub.restore();
    });

    it('should only call setupAPIRoutes when running in regular mode', () => {
      const setupAPIRoutesStub = sinon.stub();
      const setupSigningRoutesStub = sinon.stub();

      const { app } = proxyquire('../../src/expressApp', {
        './clientRoutes': {
          setupAPIRoutes: setupAPIRoutesStub,
          setupSigningRoutes: setupSigningRoutesStub,
          setupLightningSignerNodeRoutes: sinon.stub(),
        },
      });

      const args: any = {
        env: 'test',
        signerMode: undefined,
      };

      app(args);
      setupAPIRoutesStub.should.have.been.calledOnce();
      setupSigningRoutesStub.called.should.be.false();
    });

    it('should only call setupLightningSignerNodeRoutes when running with lightningSignerFileSystemPath', () => {
      const setupAPIRoutesStub = sinon.stub();
      const setupSigningRoutesStub = sinon.stub();
      const setupLightningSignerNodeRoutesStub = sinon.stub();
      const { app } = proxyquire('../../src/expressApp', {
        './clientRoutes': {
          setupAPIRoutes: setupAPIRoutesStub,
          setupSigningRoutes: setupSigningRoutesStub,
          setupLightningSignerNodeRoutes: setupLightningSignerNodeRoutesStub,
        },
        fs: {
          readFileSync: () => {
            return validLightningSignerConfigJSON;
          },
        },
      });

      const args: any = {
        env: 'test',
        lightningSignerFileSystemPath: 'lightningSignerFileSystemPath',
      };

      app(args);
      setupLightningSignerNodeRoutesStub.should.have.been.calledOnce();
      setupAPIRoutesStub.should.have.been.calledOnce();
      setupSigningRoutesStub.called.should.be.false();
    });

    it('should only call setupSigningRoutes when running in signer mode', () => {
      const setupAPIRoutesStub = sinon.stub();
      const setupSigningRoutesStub = sinon.stub();

      const { app } = proxyquire('../../src/expressApp', {
        './clientRoutes': {
          setupAPIRoutes: setupAPIRoutesStub,
          setupSigningRoutes: setupSigningRoutesStub,
          setupLightningSignerNodeRoutes: sinon.stub(),
        },
        fs: {
          readFileSync: () => {
            return validPrvJSON;
          },
        },
      });

      const args: any = {
        env: 'test',
        signerMode: 'signerMode',
        signerFileSystemPath: 'signerFileSystemPath',
      };

      app(args);
      setupSigningRoutesStub.should.have.been.calledOnce();
      setupAPIRoutesStub.called.should.be.false();
    });

    it('should require a signerFileSystemPath and signerMode are both set when running in signer mode', function () {
      const args: any = {
        env: 'test',
        signerMode: 'signerMode',
        signerFileSystemPath: undefined,
      };

      (() => expressApp(args)).should.throw({
        name: 'ExternalSignerConfigError',
        message: 'signerMode and signerFileSystemPath must both be set in order to run in external signing mode.',
      });

      args.signerMode = undefined;
      args.signerFileSystemPath = 'signerFileSystemPath';
      (() => expressApp(args)).should.throw({
        name: 'ExternalSignerConfigError',
        message: 'signerMode and signerFileSystemPath must both be set in order to run in external signing mode.',
      });

      const readFileStub = sinon.stub(fs, 'readFileSync').returns(validPrvJSON);
      args.signerMode = 'signerMode';
      (() => expressApp(args)).should.not.throw();

      readFileStub.restore();
    });

    it('should require that an externalSignerUrl and signerMode are not both set', function () {
      const args: any = {
        env: 'test',
        signerMode: 'signerMode',
        externalSignerUrl: 'externalSignerUrl',
      };
      (() => expressApp(args)).should.throw({
        name: 'ExternalSignerConfigError',
        message: 'signerMode or signerFileSystemPath is set, but externalSignerUrl is also set.',
      });

      args.signerMode = undefined;
      (() => expressApp(args)).should.not.throw();
    });

    it('should require that signerMode and lightningSignerFileSystemPath not coexist', function () {
      const args: any = {
        env: 'test',
        signerMode: 'signerMode',
        signerFileSystemPath: 'signerFileSystemPath',
        lightningSignerFileSystemPath: 'lightningSignerFileSystemPath',
      };
      (() => expressApp(args)).should.throw({
        name: 'LightningSignerConfigError',
        message: 'signerMode and lightningSignerFileSystemPath cannot be set at the same time.',
      });

      const readFileStub = sinon.stub(fs, 'readFileSync').returns(validLightningSignerConfigJSON);
      args.signerMode = undefined;
      args.signerFileSystemPath = undefined;
      (() => expressApp(args)).should.not.throw();
      readFileStub.restore();
    });

    it('should require that an signerFileSystemPath contains a parsable json', function () {
      const args: any = {
        env: 'test',
        signerMode: 'signerMode',
        signerFileSystemPath: 'invalidSignerFileSystemPath',
      };
      (() => expressApp(args)).should.throw();

      const invalidPrv = '{"invalid json"}';
      const readInvalidStub = sinon.stub(fs, 'readFileSync').returns(invalidPrv);
      (() => expressApp(args)).should.throw();
      readInvalidStub.restore();

      const readValidStub = sinon.stub(fs, 'readFileSync').returns(validPrvJSON);
      (() => expressApp(args)).should.not.throw();
      readValidStub.restore();
    });

    it('should require that an lightningSignerFileSystemPath contains a parsable json', function () {
      const args: any = {
        env: 'test',
        lightningSignerFileSystemPath: 'lightningSignerFileSystemPath',
      };
      (() => expressApp(args)).should.throw();

      const invalidPrv = '{"invalid json"}';
      const readInvalidStub = sinon.stub(fs, 'readFileSync').returns(invalidPrv);
      (() => expressApp(args)).should.throw();
      readInvalidStub.restore();

      const readValidStub = sinon.stub(fs, 'readFileSync').returns(validLightningSignerConfigJSON);
      (() => expressApp(args)).should.not.throw();
      readValidStub.restore();
    });

    it('should set keepAliveTimeout and headersTimeout if specified in config for HTTP server', async function () {
      const createServerStub = sinon.stub(http, 'createServer').callsFake(() => {
        return { listen: sinon.stub(), setTimeout: sinon.stub() };
      });

      const args: any = {
        env: 'test',
        bind: 'localhost',
        keepAliveTimeout: 5000,
        headersTimeout: 10000,
      };

      const server = await createServer(args, null as any);

      server.keepAliveTimeout.should.equal(args.keepAliveTimeout);
      server.headersTimeout.should.equal(args.headersTimeout);

      createServerStub.restore();
    });

    it('should set keepAliveTimeout and headersTimeout if specified in config for HTTPS server', async function () {
      const createServerStub = sinon.stub(https, 'createServer').callsFake(() => {
        return { listen: sinon.stub(), setTimeout: sinon.stub() };
      });

      const args: any = {
        env: 'test',
        bind: 'localhost',
        sslKey: 'ssl-key',
        sslCert: 'ssl-cert',
        keepAliveTimeout: 5000,
        headersTimeout: 10000,
      };

      const server = await createServer(args, null as any);

      server.keepAliveTimeout.should.equal(args.keepAliveTimeout);
      server.headersTimeout.should.equal(args.headersTimeout);

      createServerStub.restore();
    });

    it('should not set keepAliveTimeout and headersTimeout if not specified in config for HTTP server', async function () {
      const createServerStub = sinon.stub(http, 'createServer').callsFake(() => {
        return { listen: sinon.stub(), setTimeout: sinon.stub() };
      });

      const args: any = {
        env: 'test',
        bind: 'localhost',
        // keepAliveTimeout and headersTimeout are not specified
      };

      const server = await createServer(args, null as any);

      should(server.keepAliveTimeout).be.undefined();
      should(server.headersTimeout).be.undefined();

      createServerStub.restore();
    });

    it('should not set keepAliveTimeout and headersTimeout if not specified in config for HTTPS server', async function () {
      const createServerStub = sinon.stub(https, 'createServer').callsFake(() => {
        return { listen: sinon.stub(), setTimeout: sinon.stub() };
      });

      const args: any = {
        env: 'test',
        bind: 'localhost',
        sslKey: 'ssl-key',
        sslCert: 'ssl-cert',
        // keepAliveTimeout and headersTimeout are not specified
      };

      const server = await createServer(args, null as any);

      should(server.keepAliveTimeout).be.undefined();
      should(server.headersTimeout).be.undefined();

      createServerStub.restore();
    });
  });
});
